A complete developer guide to Bluetooth Classic and BLE โ from permissions to GATT profiles, scanning, pairing, and data transfer.
Android's Bluetooth subsystem provides a framework API to discover, pair, connect, and exchange data with Bluetooth-enabled hardware โ classic (BR/EDR) and low-energy (BLE).
Basic Rate / Enhanced Data Rate. Used for continuous, high-bandwidth streaming โ audio, file transfer, serial emulation (SPP). Operates at 2.4 GHz with up to 3 Mbps throughput.
Audio ยท Serial ยท HIDOptimized for intermittent, small-payload communication. Ideal for sensors, wearables, beacons. Uses GATT profiles. Consumes ~100x less power than classic BT at rest.
Sensors ยท Beacons ยท IoTThe entry point for all Bluetooth operations. A singleton representing the local BT radio. Used to start discovery, query state, get bonded devices, and open RFCOMM sockets.
System ServiceSystem service class (API 18+). Provides access to BluetoothAdapter and manages connected devices. Obtained via getSystemService(BLUETOOTH_SERVICE).
Permission requirements changed significantly with Android 12 (API 31). You must declare the right combination based on your target SDK.
Android โค 11 (API โค 30)
<!-- AndroidManifest.xml -->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Required for discovery on API 23โ30 -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION" />Android 12+ (API 31+)
<!-- Scan for other BT devices -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Connect to paired/known devices -->
<uses-permission
android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Make device discoverable -->
<uses-permission
android:name="android.permission.BLUETOOTH_ADVERTISE" />The BluetoothAdapter cycles through well-defined states. Monitor them via BroadcastReceiver to react to hardware changes in real time.
| Constant | Value | Meaning | Status |
|---|---|---|---|
STATE_OFF | 10 | Bluetooth radio is fully off | Inactive |
STATE_TURNING_ON | 11 | Radio powering up (transitional) | Transitioning |
STATE_ON | 12 | Bluetooth is fully on and operational | Active |
STATE_TURNING_OFF | 13 | Radio powering down (transitional) | Transitioning |
// Register a BroadcastReceiver to monitor state changes
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(
BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR
)
when (state) {
BluetoothAdapter.STATE_ON -> onBluetoothEnabled()
BluetoothAdapter.STATE_OFF -> onBluetoothDisabled()
}
}
}
registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))Android provides two separate scanning mechanisms โ classic inquiry and BLE scan. Each is suited to different hardware and use cases.
Before any scan, verify BLUETOOTH_SCAN (API 31+) or ACCESS_FINE_LOCATION (API โค 30) is granted. Use ActivityResultContracts.RequestMultiplePermissions for runtime requests.
Triggers a 12-second inquiry scan for classic BR/EDR devices. Results arrive via ACTION_FOUND broadcast. Heavyweight โ interrupts active connections. Each found device returns a BluetoothDevice object with name, address, and class.
Access via adapter.bluetoothLeScanner. Call startScan() with optional ScanFilter (filter by service UUID, device name, MAC) and ScanSettings (SCAN_MODE_LOW_LATENCY, LOW_POWER, or BALANCED). Results arrive in ScanCallback.onScanResult().
Classic discovery is battery-heavy. Always call cancelDiscovery() before connecting. BLE scans auto-stop after ~30 min in background; explicitly call stopScan() in onPause/onDestroy to conserve battery.
Use adapter.bondedDevices to get the Set<BluetoothDevice> of previously paired devices. No scan needed to connect to these โ check by name or UUID and open a socket directly.
Classic Bluetooth connections use RFCOMM โ a serial-port emulation protocol โ over a BluetoothSocket. All I/O must happen off the main thread.
// Client: connect to a remote device via SPP UUID
val sppUUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
val socket: BluetoothSocket = device
.createRfcommSocketToServiceRecord(sppUUID)
// Run on a background thread (e.g. Coroutine / Thread)
withContext(Dispatchers.IO) {
adapter.cancelDiscovery() // ALWAYS cancel discovery first
socket.connect() // Blocks until connected or throws
val input = socket.inputStream
val output = socket.outputStream
output.write("Hello".toByteArray())
val buffer = ByteArray(1024)
val bytes = input.read(buffer)
}Use adapter.listenUsingRfcommWithServiceRecord(name, uuid) to create a BluetoothServerSocket. Call accept() (blocking) in a background thread. After a client connects, you get a BluetoothSocket for I/O.
Obtain the remote BluetoothDevice, call createRfcommSocketToServiceRecord(uuid). Run connect() on a background thread โ it blocks for ~12 seconds before timing out.
createRfcommSocketToServiceRecord() requires pairing. Use createInsecureRfcommSocketToServiceRecord() to skip pairing (no MITM protection). Use secure for sensitive data.
BLE communication is structured via the GATT protocol โ a hierarchy of Services, Characteristics, and Descriptors identified by UUIDs.
A formal specification defining how two BLE devices use services to communicate. Examples: Heart Rate Profile, Battery Service Profile. Each profile contains one or more Services.
A collection of data and associated behaviors. Identified by a 128-bit UUID (or short 16-bit SIG UUID). E.g., Heart Rate Service = 0x180D. A device exposes multiple services.
A data value within a Service. Has properties: READ, WRITE, NOTIFY, INDICATE. E.g., Heart Rate Measurement characteristic = 0x2A37. This is where your actual sensor data lives.
Metadata about a Characteristic. The most important is CCCD (0x2902) โ the Client Characteristic Configuration Descriptor โ which you write to enable NOTIFY or INDICATE.
// Connect to BLE device and discover services
val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices() // Trigger service discovery after connect
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(MY_SERVICE_UUID)
val char = service.getCharacteristic(MY_CHAR_UUID)
gatt.readCharacteristic(char)
}
override fun onCharacteristicRead(...) {
val data = characteristic.value // Raw ByteArray
}
override fun onCharacteristicChanged(...) {
// Called when NOTIFY fires โ real-time updates
}
})Bonding = pairing + key storage. Android handles the UI, but you must react to bonding state changes to know when it's safe to connect.
Bluetooth on Android has many sharp edges. These practices prevent the most common bugs and crashes.
Classic discovery significantly degrades connection speed and battery. Call adapter.cancelDiscovery() before any socket.connect() call โ even if you didn't start discovery yourself.
All BT I/O โ connect(), read(), write() โ is blocking. Use Kotlin Coroutines with Dispatchers.IO, or a HandlerThread. BluetoothGattCallback also arrives on a Binder thread, not Main.
BluetoothGatt is not thread-safe. Never call readCharacteristic(), writeCharacteristic(), or setCharacteristicNotification() concurrently. Use a queue and wait for each callback before the next operation.
Always call socket.close() and gatt.close() (after gatt.disconnect()) in a finally block. Leaked sockets and GATT connections cause "Too many open files" crashes and device-side resource exhaustion.
Default BLE MTU is 23 bytes (20 bytes payload). Call gatt.requestMtu(512) after connection to increase throughput. Handle the result in onMtuChanged() before large writes.
Every Bluetooth API call โ including adapter.name, bondedDevices โ throws SecurityException on API 31+ if BLUETOOTH_CONNECT is not granted. Wrap all calls or check permissions defensively.